page.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. "use client";
  2. import { useCallback, useEffect, useMemo, useState } from "react";
  3. import { useParams, useRouter } from "next/navigation";
  4. import { MessageList } from "@/components/dashboard/MessageList";
  5. import { MessageInput } from "@/components/dashboard/MessageInput";
  6. import { ChatHeader } from "@/components/dashboard/ChatHeader";
  7. import { Button } from "@/components/ui/button";
  8. import { useAuth } from "@/features/agent/hooks/useAuth";
  9. import { useWebSocket } from "@/features/agent/hooks/useWebSocket";
  10. import {
  11. fetchMessages,
  12. sendMessage as sendMessageApi,
  13. markMessagesRead,
  14. } from "@/features/agent/services/messageApi";
  15. import { fetchConversationDetail } from "@/features/agent/services/conversationApi";
  16. import {
  17. ConversationDetail,
  18. ConversationSummary,
  19. MessageItem,
  20. MessagesReadPayload,
  21. ChatWebSocketPayload,
  22. } from "@/features/agent/types";
  23. import type { WSMessage } from "@/lib/websocket";
  24. import { toast } from "@/hooks/useToast";
  25. import { getAgentWSToken } from "@/utils/storage";
  26. import { useI18n } from "@/lib/i18n/provider";
  27. export default function AgentChatPage() {
  28. const params = useParams();
  29. const router = useRouter();
  30. const { t } = useI18n();
  31. const { agent, loading: authLoading } = useAuth();
  32. const conversationId = useMemo(() => {
  33. const id = params?.conversationId;
  34. if (!id) {
  35. return null;
  36. }
  37. const parsed = Number.parseInt(String(id), 10);
  38. return Number.isNaN(parsed) ? null : parsed;
  39. }, [params]);
  40. const [messages, setMessages] = useState<MessageItem[]>([]);
  41. const [conversationDetail, setConversationDetail] =
  42. useState<ConversationDetail | null>(null);
  43. const [conversationSummary, setConversationSummary] =
  44. useState<ConversationSummary | null>(null);
  45. const [messageInput, setMessageInput] = useState("");
  46. const [loadingMessages, setLoadingMessages] = useState(true);
  47. const [sending, setSending] = useState(false);
  48. const [highlightKeyword, setHighlightKeyword] = useState("");
  49. const wsToken = getAgentWSToken() ?? undefined;
  50. const handleMarkMessagesRead = useCallback(
  51. async (
  52. conversationIdParam?: number,
  53. readerIsAgentParam?: boolean
  54. ) => {
  55. const targetConversationId = conversationIdParam ?? conversationId;
  56. const targetReaderIsAgent = readerIsAgentParam ?? true;
  57. if (!targetConversationId) {
  58. return;
  59. }
  60. const result = await markMessagesRead(
  61. targetConversationId,
  62. targetReaderIsAgent
  63. );
  64. if (!result || result.message_ids.length === 0) {
  65. return;
  66. }
  67. const idSet = new Set(result.message_ids);
  68. setMessages((prev) =>
  69. prev.map((msg) =>
  70. idSet.has(msg.id)
  71. ? {
  72. ...msg,
  73. is_read: true,
  74. read_at: result.read_at ?? msg.read_at ?? null,
  75. }
  76. : msg
  77. )
  78. );
  79. if (targetReaderIsAgent) {
  80. setConversationDetail((prev) =>
  81. prev ? { ...prev, unread_count: result.unread_count } : prev
  82. );
  83. setConversationSummary((prev) =>
  84. prev ? { ...prev, unread_count: result.unread_count } : prev
  85. );
  86. } else {
  87. setConversationDetail((prev) =>
  88. prev
  89. ? {
  90. ...prev,
  91. last_seen_at: result.read_at ?? prev.last_seen_at ?? null,
  92. }
  93. : prev
  94. );
  95. }
  96. },
  97. [conversationId]
  98. );
  99. const loadConversationDetail = useCallback(async () => {
  100. if (!conversationId) {
  101. return;
  102. }
  103. const detail = await fetchConversationDetail(conversationId);
  104. if (detail) {
  105. setConversationDetail(detail);
  106. setConversationSummary({
  107. id: detail.id,
  108. visitor_id: detail.visitor_id,
  109. agent_id: detail.agent_id,
  110. status: detail.status,
  111. created_at: detail.created_at,
  112. updated_at: detail.updated_at,
  113. last_message: detail.last_message,
  114. unread_count: detail.unread_count ?? 0,
  115. });
  116. }
  117. }, [conversationId]);
  118. const loadMessages = useCallback(async () => {
  119. if (!conversationId) {
  120. return;
  121. }
  122. setLoadingMessages(true);
  123. try {
  124. const data = await fetchMessages(conversationId);
  125. setMessages(data);
  126. // 注意:不再自动标记访客消息为已读,而是通过滚动检测来处理
  127. } catch (error) {
  128. console.error("拉取消息失败:", error);
  129. } finally {
  130. setLoadingMessages(false);
  131. }
  132. }, [conversationId]);
  133. useEffect(() => {
  134. if (!conversationId) {
  135. router.push("/agent/dashboard");
  136. }
  137. }, [conversationId, router]);
  138. useEffect(() => {
  139. if (!conversationId || !agent) {
  140. return;
  141. }
  142. loadConversationDetail();
  143. loadMessages();
  144. }, [conversationId, agent, loadConversationDetail, loadMessages]);
  145. // 与 `useMessages` 一致:默认不拉取 AI 分段消息时,访客在 AI 模式下的未读不会出现在列表中,
  146. // 仅靠滚动无法 mark,会导致未读数长期残留。
  147. useEffect(() => {
  148. if (!conversationId || !agent) {
  149. return;
  150. }
  151. if (loadingMessages) {
  152. return;
  153. }
  154. if (conversationDetail && conversationDetail.id !== conversationId) {
  155. return;
  156. }
  157. const serverUnread = Number(conversationDetail?.unread_count ?? 0);
  158. if (serverUnread <= 0) {
  159. return;
  160. }
  161. const messagesBelongToConv =
  162. messages.length === 0 ||
  163. messages.every((m) => m.conversation_id === conversationId);
  164. if (!messagesBelongToConv) {
  165. return;
  166. }
  167. const visibleVisitorUnread = messages.filter(
  168. (msg) => !msg.sender_is_agent && !msg.is_read
  169. ).length;
  170. if (visibleVisitorUnread > 0) {
  171. return;
  172. }
  173. void handleMarkMessagesRead(conversationId, true);
  174. }, [
  175. conversationId,
  176. agent,
  177. loadingMessages,
  178. messages,
  179. conversationDetail?.id,
  180. conversationDetail?.unread_count,
  181. handleMarkMessagesRead,
  182. ]);
  183. const handleNewMessage = useCallback(
  184. (message: MessageItem) => {
  185. setMessages((prev) => {
  186. const exists = prev.some((item) => item.id === message.id);
  187. if (exists) {
  188. return prev;
  189. }
  190. return [...prev, message];
  191. });
  192. setConversationDetail((prev) => {
  193. if (!prev) {
  194. return prev;
  195. }
  196. const nextUnread =
  197. !message.sender_is_agent && message.message_type !== "system_message"
  198. ? (prev.unread_count ?? 0) + 1
  199. : prev.unread_count ?? 0;
  200. return {
  201. ...prev,
  202. updated_at: message.created_at,
  203. unread_count:
  204. message.sender_is_agent || message.message_type === "system_message"
  205. ? prev.unread_count ?? 0
  206. : nextUnread,
  207. last_message: {
  208. id: message.id,
  209. content: message.content,
  210. sender_is_agent: message.sender_is_agent,
  211. message_type: message.message_type ?? "user_message",
  212. is_read: Boolean(message.is_read),
  213. read_at: message.read_at ?? null,
  214. created_at: message.created_at,
  215. },
  216. };
  217. });
  218. setConversationSummary((prev) => {
  219. if (!prev) {
  220. return prev;
  221. }
  222. const nextUnread =
  223. !message.sender_is_agent && message.message_type !== "system_message"
  224. ? (prev.unread_count ?? 0) + 1
  225. : prev.unread_count ?? 0;
  226. return {
  227. ...prev,
  228. updated_at: message.created_at,
  229. unread_count:
  230. message.sender_is_agent || message.message_type === "system_message"
  231. ? prev.unread_count ?? 0
  232. : nextUnread,
  233. last_message: {
  234. id: message.id,
  235. content: message.content,
  236. sender_is_agent: message.sender_is_agent,
  237. message_type: message.message_type ?? "user_message",
  238. is_read: Boolean(message.is_read),
  239. read_at: message.read_at ?? null,
  240. created_at: message.created_at,
  241. },
  242. };
  243. });
  244. // 注意:不再自动标记访客消息为已读,而是通过滚动检测来处理
  245. if (message.conversation_id === conversationId) {
  246. loadConversationDetail();
  247. }
  248. },
  249. [conversationId, loadConversationDetail]
  250. );
  251. const handleSendMessage = useCallback(async () => {
  252. if (!conversationId || !agent?.id || !messageInput.trim() || sending) {
  253. return;
  254. }
  255. setSending(true);
  256. try {
  257. const created = await sendMessageApi({
  258. conversationId,
  259. content: messageInput,
  260. senderId: agent.id,
  261. });
  262. setMessageInput("");
  263. if (created) {
  264. handleNewMessage(created);
  265. } else {
  266. await loadMessages();
  267. }
  268. } catch (error) {
  269. console.error(error);
  270. toast.error((error as Error).message);
  271. } finally {
  272. setSending(false);
  273. }
  274. }, [agent?.id, conversationId, messageInput, sending, handleNewMessage, loadMessages]);
  275. const handleMessagesReadEvent = useCallback(
  276. (payload: MessagesReadPayload) => {
  277. if (!conversationId) {
  278. return;
  279. }
  280. // 检查对话ID是否匹配
  281. const payloadConversationId = payload?.conversation_id;
  282. if (payloadConversationId && payloadConversationId !== conversationId) {
  283. return;
  284. }
  285. const ids = Array.isArray(payload?.message_ids)
  286. ? payload.message_ids
  287. : [];
  288. if (ids.length === 0) {
  289. return;
  290. }
  291. const readAt = payload?.read_at;
  292. const readerIsAgent = Boolean(payload?.reader_is_agent);
  293. const unreadCount =
  294. typeof payload?.unread_count === "number"
  295. ? payload.unread_count
  296. : undefined;
  297. // 对于客服端:只有当 reader_is_agent === false 时(访客读取了客服的消息),
  298. // 才更新客服消息(sender_is_agent === true)的已读状态
  299. if (readerIsAgent) {
  300. // 客服读取了访客的消息,更新未读数(不更新消息的已读状态,因为这是客服读取的)
  301. if (unreadCount !== undefined) {
  302. setConversationDetail((prev) =>
  303. prev ? { ...prev, unread_count: unreadCount } : prev
  304. );
  305. setConversationSummary((prev) =>
  306. prev ? { ...prev, unread_count: unreadCount } : prev
  307. );
  308. }
  309. return;
  310. }
  311. // 访客读取了客服的消息,更新客服消息的已读状态
  312. const messageIdSet = new Set(ids);
  313. setMessages((prev) =>
  314. prev.map((msg) =>
  315. // 只更新客服自己的消息(sender_is_agent === true)的已读状态
  316. messageIdSet.has(msg.id) && msg.sender_is_agent
  317. ? {
  318. ...msg,
  319. is_read: true,
  320. read_at: readAt ?? msg.read_at ?? null,
  321. }
  322. : msg
  323. )
  324. );
  325. // 更新访客的最后活跃时间
  326. setConversationDetail((prev) =>
  327. prev
  328. ? { ...prev, last_seen_at: readAt ?? prev.last_seen_at ?? null }
  329. : prev
  330. );
  331. },
  332. [conversationId]
  333. );
  334. const handleWebSocketMessage = useCallback(
  335. (event: WSMessage<ChatWebSocketPayload>) => {
  336. if (!event) {
  337. return;
  338. }
  339. if (event.type === "new_message" && event.data) {
  340. const data = event.data as MessageItem;
  341. if (typeof data.conversation_id === "number") {
  342. handleNewMessage(data);
  343. }
  344. } else if (event.type === "messages_read") {
  345. // 确保处理已读事件,并传入对话ID
  346. const payload = event.data as MessagesReadPayload;
  347. if (!payload.conversation_id && event.conversation_id) {
  348. payload.conversation_id = event.conversation_id;
  349. }
  350. handleMessagesReadEvent(payload);
  351. }
  352. },
  353. [handleMessagesReadEvent, handleNewMessage]
  354. );
  355. useWebSocket<ChatWebSocketPayload>({
  356. conversationId,
  357. enabled: Boolean(conversationId),
  358. isVisitor: false,
  359. agentId: agent?.id ?? undefined,
  360. wsToken,
  361. onMessage: handleWebSocketMessage,
  362. onError: (error) => {
  363. // 静默处理错误,避免影响用户体验
  364. },
  365. onClose: () => {
  366. // 静默处理关闭,避免影响用户体验
  367. },
  368. });
  369. const handleBack = useCallback(() => {
  370. router.push("/agent/dashboard");
  371. }, [router]);
  372. const unreadCount =
  373. conversationDetail?.unread_count ?? conversationSummary?.unread_count ?? 0;
  374. if (authLoading || !agent) {
  375. return (
  376. <div className="flex justify-center items-center min-h-screen bg-gray-50 text-gray-600">
  377. {t("common.loading")}
  378. </div>
  379. );
  380. }
  381. if (!conversationId) {
  382. return null;
  383. }
  384. return (
  385. <div className="flex flex-col h-screen bg-gray-50">
  386. <div className="bg-white border-b border-gray-200">
  387. <div className="flex items-center gap-4 px-4 h-16">
  388. <Button
  389. onClick={handleBack}
  390. variant="outline"
  391. size="sm"
  392. >
  393. ← {t("agent.settings.backDashboard")}
  394. </Button>
  395. <div className="flex-1">
  396. <ChatHeader
  397. conversationId={conversationId}
  398. lastSeenAt={conversationDetail?.last_seen_at}
  399. unreadCount={unreadCount}
  400. onMarkAllRead={() => handleMarkMessagesRead(conversationId, true)}
  401. onRefresh={() => {
  402. loadMessages();
  403. loadConversationDetail();
  404. }}
  405. />
  406. </div>
  407. </div>
  408. </div>
  409. <div className="flex-1 flex flex-col bg-white">
  410. <MessageList
  411. messages={messages}
  412. loading={loadingMessages}
  413. highlightKeyword={highlightKeyword}
  414. onHighlightClear={() => setHighlightKeyword("")}
  415. currentUserIsAgent={true}
  416. conversationId={conversationId}
  417. onMarkMessagesRead={handleMarkMessagesRead}
  418. />
  419. <MessageInput
  420. value={messageInput}
  421. onChange={setMessageInput}
  422. onSubmit={handleSendMessage}
  423. sending={sending}
  424. />
  425. </div>
  426. </div>
  427. );
  428. }